<!--
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
-->
<link rel="import" href="../utils/boot.html">
<link rel="import" href="../utils/array-splice.html">
<link rel="import" href="../utils/async.html">
<script>
(function() {
  'use strict';

  /**
   * Returns true if `node` is a slot element
   * @param {Node} node Node to test.
   * @return {boolean} Returns true if the given `node` is a slot
   * @private
   */
  function isSlot(node) {
    return (node.localName === 'slot');
  }

  /**
   * Class that listens for changes (additions or removals) to
   * "flattened nodes" on a given `node`. The list of flattened nodes consists
   * of a node's children and, for any children that are `<slot>` elements,
   * the expanded flattened list of `assignedNodes`.
   * For example, if the observed node has children `<a></a><slot></slot><b></b>`
   * and the `<slot>` has one `<div>` assigned to it, then the flattened
   * nodes list is `<a></a><div></div><b></b>`. If the `<slot>` has other
   * `<slot>` elements assigned to it, these are flattened as well.
   *
   * The provided `callback` is called whenever any change to this list
   * of flattened nodes occurs, where an addition or removal of a node is
   * considered a change. The `callback` is called with one argument, an object
   * containing an array of any `addedNodes` and `removedNodes`.
   *
   * Note: the callback is called asynchronous to any changes
   * at a microtask checkpoint. This is because observation is performed using
   * `MutationObserver` and the `<slot>` element's `slotchange` event which
   * are asynchronous.
   *
   * An example:
   * ```js
   * class TestSelfObserve extends Polymer.Element {
   *   static get is() { return 'test-self-observe';}
   *   connectedCallback() {
   *     super.connectedCallback();
   *     this._observer = new Polymer.FlattenedNodesObserver(this, (info) => {
   *       this.info = info;
   *     });
   *   }
   *   disconnectedCallback() {
   *     super.disconnectedCallback();
   *     this._observer.disconnect();
   *   }
   * }
   * customElements.define(TestSelfObserve.is, TestSelfObserve);
   * ```
   *
   * @memberof Polymer
   * @summary Class that listens for changes (additions or removals) to
   * "flattened nodes" on a given `node`.
   */
  class FlattenedNodesObserver {

    /**
     * Returns the list of flattened nodes for the given `node`.
     * This list consists of a node's children and, for any children
     * that are `<slot>` elements, the expanded flattened list of `assignedNodes`.
     * For example, if the observed node has children `<a></a><slot></slot><b></b>`
     * and the `<slot>` has one `<div>` assigned to it, then the flattened
     * nodes list is `<a></a><div></div><b></b>`. If the `<slot>` has other
     * `<slot>` elements assigned to it, these are flattened as well.
     *
     * @param {HTMLElement|HTMLSlotElement} node The node for which to return the list of flattened nodes.
     * @return {Array} The list of flattened nodes for the given `node`.
    */
    static getFlattenedNodes(node) {
      if (isSlot(node)) {
        node = /** @type {HTMLSlotElement} */(node); // eslint-disable-line no-self-assign
        return node.assignedNodes({flatten: true});
      } else {
        return Array.from(node.childNodes).map((node) => {
          if (isSlot(node)) {
            node = /** @type {HTMLSlotElement} */(node); // eslint-disable-line no-self-assign
            return node.assignedNodes({flatten: true});
          } else {
            return [node];
          }
        }).reduce((a, b) => a.concat(b), []);
      }
    }

    /**
     * @param {Element} target Node on which to listen for changes.
     * @param {?function(!Element, { target: !Element, addedNodes: !Array<!Element>, removedNodes: !Array<!Element> }):void} callback Function called when there are additions
     * or removals from the target's list of flattened nodes.
    */
    constructor(target, callback) {
      /**
       * @type {MutationObserver}
       * @private
       */
      this._shadyChildrenObserver = null;
      /**
       * @type {MutationObserver}
       * @private
       */
      this._nativeChildrenObserver = null;
      this._connected = false;
      /**
       * @type {Element}
       * @private
       */
      this._target = target;
      this.callback = callback;
      this._effectiveNodes = [];
      this._observer = null;
      this._scheduled = false;
      /**
       * @type {function()}
       * @private
       */
      this._boundSchedule = () => {
        this._schedule();
      };
      this.connect();
      this._schedule();
    }

    /**
     * Activates an observer. This method is automatically called when
     * a `FlattenedNodesObserver` is created. It should only be called to
     * re-activate an observer that has been deactivated via the `disconnect` method.
     *
     * @return {void}
     */
    connect() {
      if (isSlot(this._target)) {
        this._listenSlots([this._target]);
      } else if (this._target.children) {
        this._listenSlots(this._target.children);
        if (window.ShadyDOM) {
          this._shadyChildrenObserver =
            ShadyDOM.observeChildren(this._target, (mutations) => {
              this._processMutations(mutations);
            });
        } else {
          this._nativeChildrenObserver =
            new MutationObserver((mutations) => {
              this._processMutations(mutations);
            });
          this._nativeChildrenObserver.observe(this._target, {childList: true});
        }
      }
      this._connected = true;
    }

    /**
     * Deactivates the flattened nodes observer. After calling this method
     * the observer callback will not be called when changes to flattened nodes
     * occur. The `connect` method may be subsequently called to reactivate
     * the observer.
     *
     * @return {void}
     */
    disconnect() {
      if (isSlot(this._target)) {
        this._unlistenSlots([this._target]);
      } else if (this._target.children) {
        this._unlistenSlots(this._target.children);
        if (window.ShadyDOM && this._shadyChildrenObserver) {
          ShadyDOM.unobserveChildren(this._shadyChildrenObserver);
          this._shadyChildrenObserver = null;
        } else if (this._nativeChildrenObserver) {
          this._nativeChildrenObserver.disconnect();
          this._nativeChildrenObserver = null;
        }
      }
      this._connected = false;
    }

    /**
     * @return {void}
     * @private
     */
    _schedule() {
      if (!this._scheduled) {
        this._scheduled = true;
        Polymer.Async.microTask.run(() => this.flush());
      }
    }

    /**
     * @param {Array<MutationRecord>} mutations Mutations signaled by the mutation observer
     * @return {void}
     * @private
     */
    _processMutations(mutations) {
      this._processSlotMutations(mutations);
      this.flush();
    }

    /**
     * @param {Array<MutationRecord>} mutations Mutations signaled by the mutation observer
     * @return {void}
     * @private
     */
    _processSlotMutations(mutations) {
      if (mutations) {
        for (let i=0; i < mutations.length; i++) {
          let mutation = mutations[i];
          if (mutation.addedNodes) {
            this._listenSlots(mutation.addedNodes);
          }
          if (mutation.removedNodes) {
            this._unlistenSlots(mutation.removedNodes);
          }
        }
      }
    }

    /**
     * Flushes the observer causing any pending changes to be immediately
     * delivered the observer callback. By default these changes are delivered
     * asynchronously at the next microtask checkpoint.
     *
     * @return {boolean} Returns true if any pending changes caused the observer
     * callback to run.
     */
    flush() {
      if (!this._connected) {
        return false;
      }
      if (window.ShadyDOM) {
        ShadyDOM.flush();
      }
      if (this._nativeChildrenObserver) {
        this._processSlotMutations(this._nativeChildrenObserver.takeRecords());
      } else if (this._shadyChildrenObserver) {
        this._processSlotMutations(this._shadyChildrenObserver.takeRecords());
      }
      this._scheduled = false;
      let info = {
        target: this._target,
        addedNodes: [],
        removedNodes: []
      };
      let newNodes = this.constructor.getFlattenedNodes(this._target);
      let splices = Polymer.ArraySplice.calculateSplices(newNodes,
        this._effectiveNodes);
      // process removals
      for (let i=0, s; (i<splices.length) && (s=splices[i]); i++) {
        for (let j=0, n; (j < s.removed.length) && (n=s.removed[j]); j++) {
          info.removedNodes.push(n);
        }
      }
      // process adds
      for (let i=0, s; (i<splices.length) && (s=splices[i]); i++) {
        for (let j=s.index; j < s.index + s.addedCount; j++) {
          info.addedNodes.push(newNodes[j]);
        }
      }
      // update cache
      this._effectiveNodes = newNodes;
      let didFlush = false;
      if (info.addedNodes.length || info.removedNodes.length) {
        didFlush = true;
        this.callback.call(this._target, info);
      }
      return didFlush;
    }

    /**
     * @param {!Array<Element|Node>|!NodeList<Node>} nodeList Nodes that could change
     * @return {void}
     * @private
     */
    _listenSlots(nodeList) {
      for (let i=0; i < nodeList.length; i++) {
        let n = nodeList[i];
        if (isSlot(n)) {
          n.addEventListener('slotchange', this._boundSchedule);
        }
      }
    }

    /**
     * @param {!Array<Element|Node>|!NodeList<Node>} nodeList Nodes that could change
     * @return {void}
     * @private
     */
    _unlistenSlots(nodeList) {
      for (let i=0; i < nodeList.length; i++) {
        let n = nodeList[i];
        if (isSlot(n)) {
          n.removeEventListener('slotchange', this._boundSchedule);
        }
      }
    }

  }

  Polymer.FlattenedNodesObserver = FlattenedNodesObserver;

})();
</script>
